每个DPC++(或SYCL)程序都是一个C++程序。SYCL和DPC++都不依赖于对C++，可以通过模板和Lambda函数实现。\par

SYCL编译器要以依赖于SYCL规范的内置优化。缺乏SYCL内置优化的标准C++编译器，无法达到支持SYCL的编译器的性能水平。\par

接下来，将了解DPC++和SYCL的关键属性:单源模式、主机、设备、内核代码和异步任务图。\par

\hspace*{\fill} \par %插入空行
\textbf{单源模式}

程序可以是单源的，同一个翻译单元既包含要在设备上执行的计算内核的代码，也包含协调这些计算内核执行的主机代码。开发者仍然可以将程序源划分为不同的文件，分别用于表示主机和设备代码。\par

\hspace*{\fill} \par %插入空行
\textbf{主机}

每个程序都是从主机开始运行的，并且程序中的大部分代码通常为主机编写。目前为止，主机一直都是CPU。标准不要求这样做，所以我们可以将其描述为主机。不过，主机似乎不太可能是CPU以外的任何东西，因为主机程序需要完全支持C++17，以便支持所有DPC++和SYCL程序。我们很快就会了解到，在设备端不需要支持所有的C++17特性。\par


\hspace*{\fill} \par %插入空行
\textbf{设备}

程序中使用多个设备是异构编程的原因。这就是为什么在之前对异构系统进行解释之后，“设备”这个词在本章中反复出现的原因。异构系统中的设备集合可以包括GPUI、FPGA、DSP、ASIC、CPU和AI芯片，但不限于任何固定列表。\par

SYCL承诺的加速目标是设备端，加速计算的想法通常是将工作转移到可以加速工作完成的设备上进行。所以，需要了解如何消弭移动数据所消耗的时间，这需要不断的思考。\par

\hspace*{\fill} \par %插入空行
\textbf{共享设备}

一个有设备(如GPU)的系统上，可以想象两个或更多的程序正在运行，并且希望使用一个设备。不必是使用SYCL或DPC++的程序。如果另一个程序正在使用该设备，则其他程序需要等待，这与C++程序中使用CPU的原理相同。如果在CPU上同时运行太多的活动程序(邮件、浏览器、病毒扫描、视频编辑、照片编辑等)，任何系统都可能超载。\par

超级计算机上，当为节点(CPU+附加设备)指定应用程序时，不需要考虑共享。非超级计算机系统上，如果有多个应用程序同时使用相同的设备，那么数据并行的程序性能会受到影响。\par

一切也都能正常工作，无需对此进行特别的编程。\par

\hspace*{\fill} \par %插入空行
\textbf{内核代码}

设备端执行的代码称为内核代码。这不是SYCL或DPC++独有的概念:它是其他加速语言包括OpenCL和CUDA的内核概念。\par

内核代码有一定的限制，允许更广泛的设备支持和大量并行。内核代码中不支持的特性包括动态多态性、动态内存分配(因此没有使用new或delete操作符的对象管理)、静态变量、函数指针、运行时类型信息(RTTI)和异常处理。不允许从内核代码调用虚成员函数和变参数函数。内核代码不允许递归。\par

第3章将描述在调用内核前后，如何完成内存分配，从而确保内核代码专注于大规模并行计算。第5章将描述如何处理设备上代码的异常。\par

C++的其余部分在内核中是一样的，包括Lambda、操作符重载、模板、类和静态多态性。我们还可以与主机共享数据(见第3章)，并共享(非全局)主机变量的只读值(通过Lambda捕获)。\par

\hspace*{\fill} \par %插入空行
\textbf{内核代码:向量相加 (DAXPY)}

任何编写过计算复杂代码的程序员都应该熟悉内核。考虑实现DAXPY，代表“双精度A乘以X加Y”。代码1-2展示了DAXPY在现代Fortran, C/C++和SYCL中实现的，计算行(第3行)实际上是相同的。第4章和第10章将详细解释内核。代码1-2应该有助于消除内核难以理解的担忧。\par

图1-2 DAXPY计算在Fortran, C++和SYCL的实现
\begin{lstlisting}[caption={}]
! Fortran loop
do i = 1, n
	z(i) = alpha * x(i) + y(i)
end do

// C++ loop
for (int i=0;i<n;i++) {
	z[i] = alpha * x[i] + y[i];
}

// SYCL kernel
myq.parallel_for(range{n},[=](id<1> i) {
	z[i] = alpha * x[i] + y[i];
}).wait();
\end{lstlisting}

\hspace*{\fill} \par %插入空行
\textbf{异步任务图}

SYCL/DPC++编程的异步特性是有必要了解的。理解异步编程至关重要，原因有二:(1)正确使用可以获得更好的性能(更好的扩展性)，(2)错误会导致并行编程错误(通常是竞争条件)，从而使程序变得不可靠。\par

之所以具有异步特性，是因为通过请求操作的“队列”传输到设备。主程序将请求操作提交到队列中，然后程序继续运行，不等待结果。这种不等待很重要，这样就可以让计算资源(设备和主机)处于忙碌状态。如果必须等待，这将占用主机资源。当设备完成时，还会产生串行瓶颈，直到新工作入队。Amdahl定律惩罚的是花在不并行工作上的时间，我们需要构建我们的程序，以便在设备繁忙时，在设备和主机之间来回移动数据，并在执行任务时保持设备和主机的计算能力。如果不这样做，Amdahl法则将会惩罚我们。\par

第4章将把程序看作异步任务图，第8章会扩展这个概念。\par

\hspace*{\fill} \par %插入空行
\textbf{出错时的条件竞争}

第一个代码示例(代码1-1)中，在第18行做了\textit{wait}，以避免result可用前进行使用。相同的代码示例中，还有另一件事情——第14行使用\textit{std::memcpy}加载输入。由于\textit{std::memcpy}在主机上运行，所以直到第15行完成后，才执行第16行及以后的代码。阅读了第3章之后，可能会使用myQ.memcpy(使用SYCL)，在代码1-3的第8行。因为这是队列提交，所以不能保证它会在第10行之前完成。这将引起条件竞争，是并行编程的Bug。当程序的两部分同时访问相同的数据时，就存在条件竞争。我们希望使用第8行写入数据，然后在第10行读取数据，所以不希望在第8行完成之前执行第17行!这样的条件竞争会使程序的结果不可预测——在不同的系统和不同的运行情况下得到不同的结果。解决这个问题的方法是显式地等待myQ，将\textit{.wait()}添加到第8行末尾，但这不是最好的解决办法。可以使用事件来解决这个问题(第8章)。作为一种替代方案，第7章将看到如何使用缓冲区和访问器的编程方式，让SYCL管理依赖关系并自动等待。\par

图1-3 添加条件竞争
\begin{lstlisting}[caption={}]
// ...we are changing one line from Figure 1-1
char *result = malloc_shared<char>(sz, Q);

// Introduce potential data race!
// We don't define a dependence
// to ensure correct ordering with
// later operations.
Q.memcpy(result,secret.data(),sz);

Q.parallel_for(sz,[=](auto&i) {
	result[i] -= 1;
}).wait();

// ...
\end{lstlisting}

添加\textit{wait()}强制在\textit{memcpy}和内核之间进行主机同步，这与让设备一直处于繁忙状态的建议相反。\par

为了检测程序中的数据条件竞争(包括内核)，可以使用一些工具，如Intel Inspector(可以使用前面“获得DPC++编译器”中提到的oneAPI工具)。这些工具使用的方式并不适用于所有设备。检测条件竞争最好的方法是让所有内核都运行在一个CPU上，这可以作为开发期间的一种调试技巧。这个调试技巧会在第2章中以方法2的形式进行讨论。\par

\begin{tcolorbox}[colback=red!5!white,colframe=red!75!black]
第4章将告诉我们“Lambda无害”，为了更好地使用DPC++、SYCL和现代C++，我们应该去了解Lambda函数。
\end{tcolorbox}

\hspace*{\fill} \par %插入空行
\textbf{C++ Lambda函数}

并行编程技术大量使用的现代C++的特性是Lambda表达式。内核(在设备上运行的代码)可以用多种方式表示，最常见的一种是Lambda函数。第10章讨论了内核可以采用的所有形式，包括Lambda函数。这里有一个关于C++ Lambda函数的复习，以及关于使用定义内核的注意事项。前面的章节中学习了更多关于SYCL的知识之后，第10章会对内核方面进行扩展。\par

图1-3中的代码有一个Lambda函数。在C++中，Lambda以一个方括号开始，在结束方括号之前的信息表示如何捕获在Lambda中使用，但没有显式地作为参数传递给它的变量。对于内核，必须捕获方括号中等号表示的值。\par

C++11中引入了对Lambda表达式的支持。它们用于创建匿名函数对象(尽管我们可以将它们赋值给命名变量)，这些对象可以从外围作用域捕获变量。C++ Lambda表达式的基本语法是：\par

\begin{tcolorbox}[colback=green!5!white,colframe=green!75!black]
[ capture-list ] ( params ) -> ret { body }
\end{tcolorbox}

\begin{itemize}
	\item \textit{capture-list}是一个以逗号分隔的捕获列表，通过在捕获列表中列出变量名来按值捕获。通过引用来捕获变量，在它前面加上一个\&号，例如：\&v。还有适用于所有作用自动变量:[=]是用来捕获所有变量和当前对象的引用，[\&]是用来捕获所有变量以及当前对象的引用，而[]就什么都不捕获。对于SYCL，总使用[=]，因为在内核中不允许通过引用捕获变量。根据C++标准，Lambda中不包含全局变量。非全局静态变量可以在内核中使用，但只有当它们是const时才可以。
	\item \textit{params}是函数参数的列表，就像命名函数一样。SYCL提供了一些参数来标识正在调用内核处理的元素:这可以是唯一的id(一维的)，也可以是2维或3维的id。这些将在第4章中讨论。
	\item \textit{ret}是返回类型。如果->ret未指定，则从return语句推断。没有return语句或没有值的return，意味着返回类型为void。SYCL内核必须总是有一个返回类型void，所以可以不用这种方式来为内核指定返回类型。
	\item \textit{body}是函数体。对于SYCL内核的内容有一些限制(参见本章前面的“内核代码”部分)。
\end{itemize}

图1-4 C++代码中的Lambda函数
\begin{lstlisting}[caption={}]
int i = 1, j = 10, k = 100, l = 1000;

auto lambda = [i, &j] (int k0, int &l0) -> int {
	j = 2* j;
	k0 = 2* k0;
	l0 = 2* l0;
	return i + j + k0 + l0;
};

print_values( i, j, k, l );
std::cout << "First call returned "<< lambda( k, l ) << "\n";
print_values( i, j, k, l );
std::cout << "Second call returned "<< lambda( k, l ) << "\n";
print_values( i, j, k, l );
\end{lstlisting}

图1-5 图1-4中的Lambda函数代码的输出
\begin{tcolorbox}[colback=white,colframe=black]
i == 1 \\
j == 10 \\
k == 100 \\
l == 1000 \\
First call returned 2221 \\
i == 1 \\
j == 20 \\
k == 100 \\
l == 2000 \\
Second call returned 4241 \\
i == 1 \\
j == 40 \\
k == 100 \\
l == 4000 
\end{tcolorbox}

图1-4展示了一个C++ Lambda表达式，通过值捕获变量i，通过引用捕获变量j。还有一个参数k0和另一个通过引用接收的参数l0。运行此示例将产生如代码1-5所示的输出结果。\par

可以把Lambda表达式看作函数对象的实例，编译器会创建类定义。例如，前面的示例中使用的Lambda表达式类似于代码1-6中所示的类实例。无论在哪里使用C++ Lambda表达式，都可以用函数对象的实例来替代它，如图1-6所示。\par

当定义函数对象时，需要给它分配一个名称(图1-6中的Functor)。内联表示的Lambda(如图1-4所示)是匿名的，不需要名称。\par

图1-6 用函数对象代替Lambda(详见第10章)
\begin{lstlisting}[caption={}]
class Functor{
public:
	Functor(int i, int &j) : my_i{i}, my_jRef{j} { }
	
	int operator()(int k0, int &l0) {
		my_jRef = 2 * my_jRef;
		k0 = 2 * k0;
		l0 = 2 * l0;
		return my_i + my_jRef + k0 + l0;
	}

private:
	int my_i;
	int &my_jRef;
};
\end{lstlisting}

\hspace*{\fill} \par %插入空行
\textbf{可移植性和直接编程}

可移植性是SYCL和DPC++的目标，但两者都不能保证。一种语言和编译器所能做的，就是在需要的时候更容易在应用程序中实现可移植性。\par

可移植性比较复杂，包括功能和性能可移植性。功能可移植性，程序可以在各种平台上编译和运行。性能可移植性，程序能够在各种平台上获得合理的性能。虽然这是相当软的定义，但相反的情况可能会更清楚——我们不想写一个程序，其在一个平台上运行得非常快，但在另一个平台上运行得非常慢。事实上，我们更希望能充分利用运行它的任何平台。作为开发者，考虑到异构系统中设备的多样性，性能可移植性需要我们付出更多的努力。\par

幸运的是，SYCL定义了可以提高性能可移植性的方式。首先，通用内核可以在任何地方运行。少数情况下，这可能就足够了。更常见的是，重要内核的几个版本可能是为不同类型的设备编写。具体来说，一个内核可能有一个通用GPU和通用CPU版本。有时候，可能想为特定的设备(特定的GPU)专门设计内核。当这种情况发生时，可以编写多个版本，并针对不同的GPU模型对每个版本进行定制化。或者我们可以参数化一个版本，使用GPU的属性来修改现有的GPU内核代码，以适应当前的GPU。\par

作为开发者，我需要设计有效的性能可移植性计划，SYCL定义了一些结构来帮助我们实现这个计划。正如前面提到的，可以通过以下方式对功能进行分层:首先是针对所有设备的内核，然后根据需要逐步引入更特化的内核。这听起来很棒，但程序的整体流程也可能产生深远的影响，因为数据移动和算法选择也很重要。了解了这一点，我们就可以理解为什么SYCL(或其他直接编程解决方案)没有解决性能可移植性。但作为工具，可以帮助我们应对这些挑战。\par










